User Propagators
User propagators allow implementing custom theory solvers outside of z3. The following example illustrates using propagators for solving over the reflexive transitive closure (RTC) of a binary relation over a finite domain. It is possible to axiomatize the reflexive transitive closure as a finite lookup table, but the representation explodes (quadratically) when the size of the finite domain is large.
We represent the relation outside of z3. For sake of simplicity the example maintains the RTC as a lookup table as well (so it does not scale as well as an an implicit representation of the RTC). It also only implements a very basic set of rules for enforcing RTC over the finite domain. We leave optimizations as a fun project you could explore while learning User Propagators. For example, you can implement inference rules that check that the asserted binary relations are consistent with the rules of transitivity.
Programming decision procedures is generally not a trivial task. The UserPropagate API attempts to enable new decision procedures and keeping a relatively low barrier of entry. Nevertheless, even a simple theory that we are going to explore exhibits its own subtleties. Program user propagators at your own risk.
A Problem Instance
We take a problem instance from a user GitHub question on how to scale reasoning with RTC.
The smaller example uses two relations <=Sort
and <=SortSyntax
that are reflexive and transitive
and they are defined over the finite enumeration sort Sort
. We elide the declarations of the
two binary relations in the input and instead declare them outside of SMTLIB.
This allows us to declare the relations such that the propagator is notified whenever a new
predicate over <=Sort
or <=SortSyntax
is created.
from z3 import *
example = """
(declare-datatypes () ((Sort
(|SortInt| )
(|SortExp| )
(|SortKItem| )
(|SortKLabel| )
(|SortK| )
)))
;(declare-fun <=Sort (Sort Sort) Bool)
;(declare-fun <=SortSyntax (Sort Sort) Bool)
(declare-const |FreshVarSort_6_8_6_36_#KRewrite| Sort)
(declare-const |VarA| Sort)
(declare-const |VarB| Sort)
(declare-const |VarC| Sort)
(declare-datatypes() ((SolutionVariables (SolVars (|Sol_VarA| Sort) (|Sol_VarB| Sort) (|Sol_VarC| Sort) ))))
(declare-datatypes() ((Solution (Sol (vars SolutionVariables) (|Sol_FreshVarSort_6_8_6_36_#KRewrite| Sort) ))))
(define-fun theSolution () Solution (Sol (SolVars |VarA| |VarB| |VarC| ) |FreshVarSort_6_8_6_36_#KRewrite| ))
(define-fun lt ((s1 Solution) (s2 Solution)) Bool (and true (<=SortSyntax (|Sol_VarA| (vars s1)) (|Sol_VarA| (vars s2))) (<=SortSyntax (|Sol_VarB| (vars s1)) (|Sol_VarB| (vars s2))) (<=SortSyntax (|Sol_VarC| (vars s1)) (|Sol_VarC| (vars s2))) (distinct (vars s1) (vars s2))))
(assert (and true (distinct (|Sol_FreshVarSort_6_8_6_36_#KRewrite| theSolution) |SortKLabel|) ))
(define-fun |constraint4_SortExp| ((s Solution)) Bool (and true (<=Sort (|Sol_VarA| (vars s)) |SortExp|) ))
(define-fun |constraint6_SortExp| ((s Solution)) Bool (and true (<=Sort (|Sol_VarB| (vars s)) |SortExp|) ))
(define-fun |constraint3_SortExp| ((s Solution)) Bool (and true (<=Sort |SortExp| |SortExp|) (|constraint4_SortExp| s) (|constraint6_SortExp| s) ))
(define-fun |constraint8_SortExp| ((s Solution)) Bool (and true (<=Sort (|Sol_VarC| (vars s)) |SortExp|) ))
(define-fun |constraint2_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| ((s Solution)) Bool (and true (<=Sort |SortExp| (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s)) (|constraint3_SortExp| s) (|constraint8_SortExp| s) ))
(define-fun |constraint12_SortInt| ((s Solution)) Bool (and true (<=Sort (|Sol_VarA| (vars s)) |SortInt|) ))
(define-fun |constraint14_SortInt| ((s Solution)) Bool (and true (<=Sort (|Sol_VarB| (vars s)) |SortInt|) ))
(define-fun |constraint11_SortInt| ((s Solution)) Bool (and true (<=Sort |SortInt| |SortInt|) (|constraint12_SortInt| s) (|constraint14_SortInt| s) ))
(define-fun |constraint16_SortInt| ((s Solution)) Bool (and true (<=Sort (|Sol_VarC| (vars s)) |SortInt|) ))
(define-fun |constraint10_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| ((s Solution)) Bool (and true (<=Sort |SortInt| (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s)) (|constraint11_SortInt| s) (|constraint16_SortInt| s) ))
(define-fun |constraint1_Sort#RuleBody| ((s Solution)) Bool (and true (= (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s) |SortK|) (|constraint2_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| s) (|constraint10_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| s) ))
(define-fun |constraint0_Sort#RuleContent| ((s Solution)) Bool (and true (|constraint1_Sort#RuleBody| s) ))
(define-fun |constraint21_SortExp| ((s Solution)) Bool (and true (<=Sort |SortExp| |SortExp|) (|constraint6_SortExp| s) (|constraint8_SortExp| s) ))
(define-fun |constraint20_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| ((s Solution)) Bool (and true (<=Sort |SortExp| (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s)) (|constraint4_SortExp| s) (|constraint21_SortExp| s) ))
(define-fun |constraint19_Sort#RuleBody| ((s Solution)) Bool (and true (= (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s) |SortK|) (|constraint20_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| s) (|constraint10_(Sol_FreshVarSort_6_8_6_36_#KRewrite s)| s) ))
(define-fun |constraint18_Sort#RuleContent| ((s Solution)) Bool (and true (|constraint19_Sort#RuleBody| s) ))
(define-fun amb0 ((s Solution)) Bool (or (|constraint0_Sort#RuleContent| s) (|constraint18_Sort#RuleContent| s) ))
(assert (amb0 theSolution))
;(assert (not (exists ((s Solution)) (and (lt theSolution s) (amb0 s) (distinct (|Sol_FreshVarSort_6_8_6_36_#KRewrite| s) |SortKLabel|) ))))
(assert (not (exists ((var SolutionVariables) (s Sort)) (and (lt theSolution (Sol var s)) (amb0 (Sol var s)) (distinct s |SortKLabel|) ))))
(check-sat)
(get-model)
;(assert (or false (distinct |VarA| |SortInt|) (distinct |VarB| |SortInt|) (distinct |VarC| |SortInt|) ))
;(check-sat)
"""
Propagate Functions
We can access the sort Sort
from the SMTLIB input by declaring it as a DatatypeSort
.
Then the two functions we did not define in the SMTLIB input are declared as PropagateFunction
.
This declaration instructs z3 to invoke callbacks whenever a new term headed by the declared
function is introduced to the solver. It could be a term that is part of the input, or it could
be a term that is created dynamically using quantifier instantiation.
Axiomatizing RTCs
Union Find
We use a simple union find with support for tracking values.
Finally, the propagator
It uses a good set of features exposed for user propagators. It illustrates
- Instantiation of basic methods for backtracking and cloning
- Registering terms to be tracked in callbacks
- Callbacks for when terms are assigned fixed values
- Callbacks for new equalities are detected by z3
- Callbacks when new terms are created using
PropagateFunction
- Callbacks when z3's search is done case splitting
TC as a subclass
The TC
solver is instantiated as a subclass of UserPropagateBase
.
The subclass implements three virtual methods push
, pop
, and fresh
.
It adds other optional callbacks for fixed
, final
, eq
created
and
initializes data-structures for tracking the reflexive transitive closure of
relations <=Sort
and <=SortSyntax
.
The push/pop callbacks are invoked when z3's CDCL-based solver case splits and backtracks. State changes within the scope of a push have to be reverted to an equivalent state. It is not a requirement that the state be exactly the same, but it should preserve satisfiability relative to the previous time it was in the same scope. We restore state using functions that are pushed on a trail whenever some side-effect occurs within a scope.
The fresh callback is invoked when z3 creates a nested solver. The main use case for the nested solver is
z3's model-based quantifier instantiation method, MBQI. MBQI uses the nested solver to check if a quantifier
is satisfied relative to a candidate model. The nested solver is opaque to the user propagator. Instead
it sees a new (unique) context
object. For our use case the nested context shares the same tables for expressions
so we don't have to maintain separate copies of expressions. If the nested solver is created using threads
(we are not enabling parallel solving for this example), the expression tables cannot be assumed the same
and TC would have to set up separate expressions for the new context.
The constructor of TC takes an optional solver s
and an optional context ctx
.
Initially it is created using a solver (and not a context).
Nested instances are created using a context (and not a solver).
Handling assignments to fixed values
When x
is fixed to value v
, we check if it immediately
triggers a conflict. If it doesn't we add it to a trail
that is checked on final. It is assumed that during final
check_conflict
is conclusive (returns True) as all equatlities
are known between arguments to <=Sort and <=SortSyntax.
New Terms
When a new term t
is created using one of the PropagateFunction
declarations we receive a callback.
It allows register t
and its two arguments with z3. In return for registering these terms, z3 will
invoke fixed
and eq
callbacks when z3 infers a fixed value assignment or a new equality between
tracked terms.
There is a subtle component in this callback: We need to register all constructors with the solver
otherwise, it could be that a constructor does not occur as argument of any
of the tracked predicates, but gets used by the solver and merged with
one of the arguments during search. If the constructor isn't registered
final()
cannot resolve the value associated with a x
or y
.
Equality callbacks
Terms that have been registered using self.add
are tracked by the solver.
The solver issues the equality callback when two registered terms are equated.
The number of equality callbacks for N terms that are equal is N-1, corresponding
to a spanning tree. So not all equalities are presented in callbacks and the client
can track equivalence classes by using a union-find data-structure as we are doing.